
這篇文章會講清楚 kotlin.contracts 的 callsInPlace 和 returns implies 兩種效果,理解為什麼 stdlib 的 let、run、require、check、checkNotNull 都需要 contract
| Kotlin | C# | 備註 |
|---|---|---|
kotlin.contracts |
System.Diagnostics.Contracts(Code Contracts) |
名字像而已,兩者範圍差很多,見下方說明 |
callsInPlace(block, EXACTLY_ONCE) |
無對應 | C# 不支援,見下方說明 |
returns() implies condition |
無對應 | C# 用 [NotNullWhen] 等 attribute |
這裡要澄清一個常見誤解:不是 C# 的 compiler 對 Lambda「比較聰明、不需要宣告」,而是 C# 根本不支援這種分析
C# 的 definite assignment 分析不會穿透 lambda 或 delegate,你在 lambda 內賦值一個變數,lambda 外面讀取它,照樣會吃到 CS0165 編譯錯誤,只有改用 local function 並實際呼叫它,賦值狀態才會流回外層
Kotlin 的 callsInPlace 補的正是這個洞:因為 Lambda 可能被延遲執行、多次執行或不執行,compiler 需要 contract 提供的保證,才能做 smart cast 和初始化檢查
contract 是你跟 compiler 之間的「約定」。你告訴 compiler「我這個函式裡的 Lambda 一定會被執行一次」,或「我這個函式 return 了就表示某個條件成立」。compiler 拿到這些資訊後,可以做更聰明的靜態分析
目前 contract 還在實驗階段(@ExperimentalContracts)。使用時需要 @OptIn
fun <T, R> T.myLetNoContract(block: (T) -> R): R {
return block(this)
}
val x: String
"hello".myLetNoContract { x = it } // 編譯錯誤:captured values cannot be initialized because of possible reassignments
println(x)
compiler 不知道 block 到底會不會被執行。也許 myLetNoContract 根本沒呼叫 block?也許呼叫了兩次?如果沒呼叫,x 就沒被初始化;如果呼叫兩次,val x 就被賦值兩次。compiler 不敢賭,所以第一個編譯錯誤其實發生在 lambda 內的 x = it,一個 captured 的 val 在 Lambda 裡禁止賦值。連帶後面的 println(x) 也會抱怨 x 可能沒初始化
callsInPlace 的第二個參數描述 Lambda 會被呼叫幾次
| Kind | 意思 | 適用的函式長什麼樣 |
|---|---|---|
EXACTLY_ONCE |
一定執行一次 | let、run、apply、also |
AT_LEAST_ONCE |
至少執行一次 | 內部至少跑一輪才可能結束的函式,例如 retryUntil(block) |
AT_MOST_ONCE |
最多執行一次(可能不執行) | 有條件才呼叫 block 的函式,例如 runIfEnabled(block) |
UNKNOWN |
不確定 | 預設值,等於沒給 compiler 額外資訊 |
四種都是描述「你這個函式會怎麼呼叫傳進來的 Lambda」。stdlib 的五大 scope function 都用 EXACTLY_ONCE,因為它們保證 Lambda 一定會被執行正好一次;後面兩種在 stdlib 裡很少見,通常出現在自己寫的 API 上
我們想要的行為是:呼叫這兩個函式之後,Lambda 裡賦值的 val 能被 compiler 認定為「已初始化」
@Test
fun `letWithContract 讓 val 能在 Lambda 內初始化`() {
val x: Int
10.myLetWithContract { x = it + 5 }
assertEquals(15, x)
}
@Test
fun `runWithContract 讓 val 能在 Lambda 內初始化`() {
val x: String
"hello".myRunWithContract { x = uppercase() }
assertEquals("HELLO", x)
}
@Test
fun `letWithContract 把 receiver 當參數傳進 block`() {
val result = "abc".myLetWithContract { it.length }
assertEquals(3, result)
}
這三個測試在沒有 contract 的版本上連編譯都過不了,因為 val x 在 Lambda 內賦值會被擋下。這裡不用測 null 或例外,這兩個函式只是把 Lambda 跑一次,本身不拋例外,所以測試聚焦在「val 初始化」與「receiver 傳遞」兩個行為
@OptIn(ExperimentalContracts::class)
inline fun <T, R> T.myLetWithContract(block: (T) -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block(this)
}
@OptIn(ExperimentalContracts::class)
inline fun <T, R> T.myRunWithContract(block: T.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block()
}
callsInPlace(block, EXACTLY_ONCE) 告訴 compiler block 一定會被執行正好一次。compiler 拿到這個保證後,就能確認 val x 會被初始化一次,前面的測試也就能通過編譯了
兩個函式只差 Lambda 型別:myLetWithContract 收 (T) -> R,把 this 當參數傳入;myRunWithContract 收 T.() -> R,所以 Lambda 裡可以直接寫 uppercase()。與 day 32 相比,這次多了 contract 宣告
stdlib 的 let、run 也使用這種 contract。對照 day 32 原本已標成 inline 的 myLet、myRun,這次新增的是 contract 區塊與 @OptIn(ExperimentalContracts::class)
Kotlin 的 default import 是一份固定列舉的清單:kotlin.*、kotlin.annotation.*、kotlin.collections.*、kotlin.comparisons.*、kotlin.io.*、kotlin.ranges.*、kotlin.sequences.*、kotlin.text.*,JVM 上再加 java.lang.* 和 kotlin.jvm.*。kotlin.contracts 不在裡面,所以 InvocationKind 要自己 import。kotlin.math 也一樣,用 PI、sqrt 同樣得補一行
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind // ← 這行最容易漏掉
import kotlin.contracts.contract
漏掉的話,編譯會噴出這幾個錯誤
myLamdbaTest.kt:4037:9 Error in contract description: '<Unresolved name: InvocationKind>#.<Unresolved name: EXACTLY_ONCE>#' is not a valid invocation kind.
myLamdbaTest.kt:4037:29 Unresolved reference 'InvocationKind'.
myLamdbaTest.kt:2609:32 'val' cannot be reassigned.
myLamdbaTest.kt:2610:26 Variable 'x' must be initialized.
contract { } 裡的內容會先交給 compiler 的 contract 解析器,名稱解析失敗的結果被包成 contract 的錯誤訊息丟出來,排在真正的 Unresolved reference 前面。最上面那個最醒目,看起來像 contract 語法寫錯,實際要處理的是第二個。後面兩個 val 相關的錯誤則是連鎖反應,contract 沒生效,前面測試裡的 val x 自然就無法在 Lambda 內初始化
IntelliJ 貼上這段程式碼時,打字當下的 on-the-fly auto import 不一定會補上這行,把游標移到 InvocationKind 上按 Alt + Enter 比較保險
callsInPlace 反而不用 import,它是 ContractBuilder 的 member function,在 contract { } 的 receiver scope 裡就解析得到
fun customRequire(value: Boolean) {
if (!value) throw IllegalArgumentException()
}
val input: Any = "hello"
customRequire(input is String)
// input.length ← 編譯錯誤,compiler 不知道 input 一定是 String
compiler 看到 customRequire 回傳了,不代表它知道 input is String 是 true。也許 customRequire 裡面做了別的事情,條件不一定成立。要把這個保證告訴 compiler,就要靠 returns() implies
stdlib 的 require、check、checkNotNull 都靠 returns() implies 撐起 smart cast。三者差別只在拋的例外型別:require 拋 IllegalArgumentException(參數錯)、check 拋 IllegalStateException(狀態錯)、checkNotNull 在 null 時拋 IllegalStateException 並回傳非 null 值。我們一次手刻這三個
@Test
fun `myRequire 條件為 true 時正常通過`() {
myRequire(true) { "should not throw" }
}
@Test
fun `myRequire 條件為 false 時拋 IllegalArgumentException`() {
assertThrows(IllegalArgumentException::class.java) {
myRequire(false) { "value must be positive" }
}
}
@Test
fun `myRequire 之後能 smart cast`() {
val value: Any = "hello"
myRequire(value is String) { "must be String" }
assertEquals(5, value.length) // smart cast 成功
}
@Test
fun `myCheck 條件為 true 時正常通過`() {
myCheck(true) { "should not throw" }
}
@Test
fun `myCheck 條件為 false 時拋 IllegalStateException`() {
assertThrows(IllegalStateException::class.java) {
myCheck(false) { "state is invalid" }
}
}
@Test
fun `myCheck 之後能 smart cast`() {
val value: Any = 42
myCheck(value is Int) { "must be Int" }
assertEquals(43, value + 1) // smart cast 成功
}
@Test
fun `myCheckNotNull 非 null 時回傳值並 smart cast`() {
val name: String? = "Kotlin"
val result = myCheckNotNull(name) { "name is null" }
assertEquals(6, result.length) // 回傳值非 null
assertEquals(6, name.length) // 原變數也 smart cast 成 String
}
@Test
fun `myCheckNotNull 為 null 時拋 IllegalStateException`() {
val name: String? = null
assertThrows(IllegalStateException::class.java) {
myCheckNotNull(name) { "name is null" }
}
}
myRequire 和 myCheck 各三個測試:happy path、拋例外、smart cast。myCheckNotNull 把 happy path 跟 smart cast 併成一個,因為它的回傳值本身就是非 null 的證明,再加一個 null 時拋例外
@OptIn(ExperimentalContracts::class)
inline fun myRequire(value: Boolean, lazyMessage: () -> String) {
contract {
returns() implies value
}
if (!value) {
throw IllegalArgumentException(lazyMessage())
}
}
@OptIn(ExperimentalContracts::class)
inline fun myCheck(value: Boolean, lazyMessage: () -> String) {
contract {
returns() implies value
}
if (!value) {
throw IllegalStateException(lazyMessage())
}
}
@OptIn(ExperimentalContracts::class)
inline fun <T : Any> myCheckNotNull(value: T?, lazyMessage: () -> String): T {
contract {
returns() implies (value != null)
}
if (value == null) {
throw IllegalStateException(lazyMessage())
}
return value
}
returns() implies value 告訴 compiler:如果這個函式正常 return 了(沒有拋例外),那 value 一定是 true。所以下面這段就能 smart cast
val input: Any = "hello"
myRequire(input is String) { "must be String" }
input.length // 現在可以 smart cast 了
myCheckNotNull 的 returns() implies (value != null) 更進一步:函式回傳之後,compiler 知道傳進去的 value 一定不是 null,連回傳值本身也是非 null 型別 T
三個函式已經跟 stdlib 同形,contract 區塊與例外型別都對得上,不需要再改
唯一要提的是 stdlib 把 lazyMessage 的型別寫成 () -> Any(而非我們的 () -> String),這樣訊息可以是任何物件、最後再 toString(),要更貼近 stdlib 可以調整,但對我們示範 contract 的目的沒影響
原始碼位置:kotlin.contracts 的 ContractBuilder.kt、kotlin 的 Standard.kt
stdlib 裡面大量使用 contract。對照幾個
// require
public inline fun require(value: Boolean, lazyMessage: () -> Any): Unit {
contract {
returns() implies value
}
if (!value) {
val message = lazyMessage()
throw IllegalArgumentException(message.toString())
}
}
// let
public inline fun <T, R> T.let(block: (T) -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block(this)
}
day 32 手刻的 scope function 跟 stdlib 唯一的差別就是 contract,加上 contract 之後,let 裡面初始化 val 才能通過編譯
contract { } 必須是函式體的第一個 statement。Kotlin 2.2.20 起可透過 -Xallow-contracts-on-more-functions,實驗性地在 property accessor 與部分 operator function 使用 contract;這不代表任意 member function 都已支援。contract 本身不限定 inline,但 scope function 這類要讓 Lambda 內的賦值參與外層 definite assignment 分析,通常會搭配 inline
EXACTLY_ONCE 但實際呼叫了兩次,compiler 不會出現錯誤,但程式行為可能出問題implies 的右邊只能是簡單的布林表達式(value、value != null、value is Type)第三點要注意,contract 是一種「榮譽制度」,你得自己保證宣告跟實作一致
對照表把 kotlin.contracts 對到 System.Diagnostics.Contracts,但這只是名字像,兩者的範圍差非常多,這裡展開講一下
Microsoft Research 在 2008 年推出 Code Contracts,設計哲學主要是借鏡 Eiffel 的 design by contract
Contract.Requires(x > 0 && list.Count < 100),任意布林表達式都寫得出來Contract.Ensures(Contract.Result<int>() >= 0) 描述回傳值,Contract.OldValue<T>(e) 拿方法進入前的舊值來比對,EnsuresOnThrow<T> 描述拋特定例外時該成立什麼[ContractInvariantMethod] 標一個方法,裡面寫 Contract.Invariant(this.x > this.y),每個 public 方法結束時都會檢查Contract.ForAll、Contract.Exists 描述整個集合要滿足的性質配套工具是 ccrewrite(bytecode rewriter,把契約織成 runtime 檢查)和 cccheck(靜態驗證器,用 SMT 求解在編譯期證明契約成不成立),還能從契約產生文件
Microsoft 的 docs 現在寫得很直接
Code contracts aren't supported in .NET 5+ (including .NET Core versions). Consider using Nullable reference types instead.
System.Diagnostics.Contracts.Contract 這個型別到 .NET 10 都還在 BCL 裡,但 ccrewrite 跟 cccheck 沒有跟上。API 文件自己也標了:沒有 binary rewriter,Contract.Ensures 這種契約「will not throw exceptions during run time if a contract is violated」,等於只剩註解。GitHub 上的 microsoft/CodeContracts repo 在 2023 年 7 月 15 日封存
問題出在 docs 那句 Consider using Nullable reference types instead。nullable reference types(C# 8, 2019)加上 [NotNullWhen]、[MemberNotNull] 這些 attribute,只處理 null 這一個維度,x > 0、回傳值的範圍、物件不變量,一個都表達不了。它補的是 Code Contracts 最常被用到的那個角落,不是取代整套 design by contract
真正在現代 C# 裡做前置條件檢查的,是 runtime 的 guard clause:ArgumentNullException.ThrowIfNull(x)(.NET 6)、ArgumentOutOfRangeException.ThrowIfNegative(n)(.NET 8)這類。寫起來比 Contract.Requires 囉唆,也沒有靜態驗證,但至少不用裝一整套工具鏈,編譯期證明契約那一塊,C# 就是沒有了
講到這裡要拉回來澄清一件事:kotlin.contracts 跟 Code Contracts 不是同一個層級的東西,它反而跟 C# 的 nullable attribute 是同一類
看前面「contract 的限制」那節就知道:implies 右邊只能是 value、value != null、value is Type 這種簡單布林表達式,寫不出 value > 0;compiler 也不驗證你有沒有說謊,更不會產生任何 runtime 檢查。它做的事情只有一件,餵資訊給 compiler 的 flow analysis,換 smart cast 跟 definite assignment
想在 Kotlin 做真正的前置條件檢查,用的是 require、check 這些會實際拋例外的函式,跟 C# 的 guard clause 一樣。contract 只是讓這些函式呼叫完之後,compiler 順便知道條件成立了
三者的位置可以這樣排
| 機制 | 能表達什麼 | 誰檢查 |
|---|---|---|
| Code Contracts | 任意布林、後置條件、物件不變量 | ccrewrite(runtime) + cccheck(編譯期證明) |
| C# nullable attribute | 只有 null-state | compiler 的 flow analysis |
kotlin.contracts |
簡單布林(null、is、布林變數) |
compiler 的 flow analysis,信任但不驗證 |
第一列沒有接班人。C# 和 Kotlin 都停在第二、三列,靠 compiler 分析換來的那點聰明,加上 runtime 的 guard clause
C# 的 nullable attribute 是穩定 API,Kotlin 的 contract 還掛著 @ExperimentalContracts,實務上 Kotlin contract 已穩定多年(stdlib 自己大量使用),但官方還沒拍板最終 API 形態
第九部分(day 32 到 day 35)是「進階語法篇」。我們從 scope function 出發,一路把 Kotlin Collection API 的進階語法補齊:day 32 手刻五大 scope function,day 33 做自訂運算子與中綴函式,day 34 處理泛型型變與 reified,day 35 用 contract 讓 compiler 更聰明。這四篇的共同點是,它們都不是「某個 Collection 操作」,而是讓你有能力自己設計出 stdlib 那種好用 API 的底層工具
| 函式 | 篇號 | 一句話用途 |
|---|---|---|
myLet / myRun |
day 32 | scope function,把物件傳進 Lambda 做運算後回傳結果 |
myWith / myApply / myAlso |
day 32 | scope function,分別處理 receiver 傳入、設定後回傳自己、副作用後回傳自己 |
MyIntList(operator fun) |
day 33 | 用 get / invoke / plus / contains 掛上 []、()、+、in |
myTo / myRepeat |
day 33 | 自訂 infix 中綴函式,配對與字串重複 |
myFilterIsInstance |
day 34 | 用 reified 在 runtime 篩出特定型別的元素 |
myLetWithContract / myRunWithContract |
day 35 | 加上 callsInPlace 的 scope function,讓 Lambda 內能初始化 val |
myRequire / myCheck / myCheckNotNull |
day 35 | 用 returns() implies 撐起 smart cast 的前置條件檢查 |
contract 解決的問題是:compiler 不知道 Lambda 的呼叫時機和次數,也不知道函式回傳代表什麼條件成立。callsInPlace 解第一個問題,讓 scope function 裡面可以初始化 val。returns implies 解第二個問題,讓 require、check 和 checkNotNull 之後可以做 smart cast。目前還是實驗性 API,但 stdlib 自己大量使用,實務上很穩定
下一篇終於是全系列最終回
同步刊登於 Blog
圖片來源:AI 產生